Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add erc4626 #1170

Open
wants to merge 80 commits into
base: main
Choose a base branch
from
Open

Conversation

andrew-fleming
Copy link
Collaborator

@andrew-fleming andrew-fleming commented Oct 1, 2024

WIP

Fixes #???

PR Checklist

  • Tests
  • Documentation
  • Added entry to CHANGELOG.md
  • Tried the feature on a public network

...still a WIP. The tests need to be refactored and improved. The idea was to make sure the logic works as expected so there's a standard (from the openzeppelin solidity tests) for any and all changes moving forward.

Some things to note and discuss:

Decimals

EIP4626 states at the end of the doc:

Although the convertTo functions should eliminate the need for any use of an EIP-4626 Vault’s decimals variable, it is still strongly recommended to mirror the underlying token’s decimals if at all possible, to eliminate possible sources of confusion and simplify integration across front-ends and for other off-chain users.

The OpenZeppelin solidity implementation checks for the underlying asset's tokens upon construction with a try/catch which defaults to 18 decimals if query fails. Given Starknet's current status with try/catch (✌️ dual dispatchers), this doesn't seem like a viable approach. If the vault is for an undeployed token, the deployment will fail. This PR proposes that the decimals (both underlying decimals and offset decimals) are explicitly defined by the contract through the ImmutableConfig.

Math

u512 Precision math for multiply and divide (u256_mul_div)

This PR leverages the corelib's wide_mul and u512_safe_div_rem_by_u256 for mathematical precision. This is set as a tmp solution and should be scrutinized further. More tests should be provided and we should look for ways to optimize.

Exponentiation

This PR requires exponentiation for converting to shares and assets. The current implementation is the brute force formula. We can definitely improve this.

This was added to the corelib (starkware-libs/cairo#6694). Will update when released

FeeConfigTrait

This PR proposes to utilize FeeConfigTrait (which is really like a hook) for contracts to integrate entry and exit fees for preview_ fns. The state-changing methods of ERC4626 rely on preview_ to determine the number of assets or shares to exchange.

Another approach that can reduce the verbosity of the traits/hooks is to have a single adjust_assets_or_shares function that accepts an ExchangeType.

    #[derive(Drop, Copy)]
    pub enum ExchangeType {
        Deposit,
        Withdraw,
        Mint,
        Redeem
    }

    pub trait ERC4626HooksTrait<TContractState> {
        (...)

        fn adjust_assets_or_shares(
            self: @ComponentState<TContractState>, exchange_type: ExchangeType, raw_amount: u256
        ) -> u256 {
            raw_amount
        }
    }

    /// Component method example
    fn preview_mint(self: @ComponentState<TContractState>, shares: u256) -> u256 {
        let raw_amount = self._convert_to_assets(shares, Rounding::Ceil);
        Hooks::adjust_assets_or_shares(self, ExchangeType::Deposit, raw_amount)
    }

The downside though is I think it's easy to misuse i.e.

#[starknet::contract]
mod Contract {
    (...)

    impl ERC4626HooksImpl of ERC4626Component::ERC4626HooksTrait<ContractState> {
        fn adjust_assets_or_shares(
            self: @ERC4626Component::ComponentState<ContractState>, exchange_type: ExchangeType, raw_amount: u256
        ) -> u256 {
            match exchange_type {
                ExchangeType::Mint => {
                    // do something
                },
                ExchangeType::Deposit => {
                    // do something
                },
                ExchangeType::Withdraw => {
                    // do something
                },
                ExchangeType::Redeem => {
                    // do something
                }
            }
        }
    }
}

IMO having a dedicated function for each exchange type is more difficult to mess up...but it's at the cost of some verbosity.

LimitsConfigTrait

This mirrors the FeeConfigTrait except that these target the limits on the max_ methods and return an Option so Option::None can point to the default. Same arguments apply for not having a single trait/hook with an ExchangeType parameter.

before_withdraw and after_deposit hooks

The before_withdraw and after_deposit hooks take inspiration from solmate's solidity implementation of erc4626. These hooks are where contracts can transfer fees calculated from the FeeConfigTrait in the implementing contract. See the Fees mock to see how this works in the proposed PR.

Copy link

codecov bot commented Oct 1, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 89.34%. Comparing base (0676415) to head (533f7ba).
Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1170      +/-   ##
==========================================
- Coverage   92.26%   89.34%   -2.93%     
==========================================
  Files          59       81      +22     
  Lines        1811     3471    +1660     
==========================================
+ Hits         1671     3101    +1430     
- Misses        140      370     +230     
Files with missing lines Coverage Δ
...s/token/src/erc20/extensions/erc4626/erc4626.cairo 100.00% <100.00%> (ø)
...token/src/erc20/extensions/erc4626/interface.cairo 100.00% <100.00%> (ø)
packages/utils/src/math.cairo 100.00% <100.00%> (ø)

... and 78 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 86debd4...533f7ba. Read the comment docs.

@andrew-fleming andrew-fleming marked this pull request as ready for review November 29, 2024 07:49
@andrew-fleming andrew-fleming mentioned this pull request Dec 3, 2024
1 task
Copy link
Member

@ericnordelo ericnordelo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andrew-fleming , the overall implementation looks great! I left a couple of comments, but my main suggestion is to simplify the fee mechanism.

With the current approach I feel it would be hard for users to follow how to implement fees, and is easy to misuse since adjust_[operation] functions doesn't have a percent let it clear on which percentages are used, or on how to transfer the fees on the Hooks accordingly from the amounts received as parameters.

I think we can do something like this to simplify the logic and make it easier to follow and use:

  • Have a configurable FEE_DENOMINATOR in ImmutableConfig for precision default to 10000 (100 is 1%)
  • Have only two functions in the FeeConfigTrait, entry_fee_numerator and exit_fee_numerator. Entry used for both deposit and mint, and exit for redeem and withdraw.
  • Remove the Hooks trait and add the transfer fee logic in _deposit and _withdraw, with an if depending on the fee being greater than 0.
  • Compute the fee and apply it where it needs to be applied (preview functions).
  • Maybe add a couple of internal functions like entry_fee_set and exit_fee_set checking that the numerators are greater than 0.

I know this will add a couple of conditions and operations to the flow when the fees are not required (set to 0), but I think it will be negligible, and it will improve the UX a lot making it easier for users to implement a fee mechanism.

const UNDERLYING_DECIMALS: u8;
const DECIMALS_OFFSET: u8;

fn validate() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since UNDERLYING_DECIMALS should be equal to the asset decimals, the validate function should assert that, if the decimals function is implemented. This way it will be a constant, but it will match the correct value. We should be able to check if decimals is implemented and recover with 0.13.4.

Requirements should be updated accordingly (including the ones of initializer).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for the having the user set UNDERLYING_DECIMALS was to avoid requiring recoverability from a call to decimals. If we'd prefer leveraging the forthcoming try/catch functionality, we can just automate the process like the solidity impl and remove the user input for underlying

packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved

/// Returns the maximum amount of the underlying asset that can be deposited into the Vault
/// for the receiver, through a deposit call.
/// If the `LimitConfigTrait` is not defined for deposits, returns 2 ** 256 - 1.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of LimitConfigTrait must always be available, even if it is the default. We should rephrase this. I don't make any specific suggestions since you will write a better sentence for sure.


/// Allows an on-chain or off-chain user to simulate the effects of their deposit at the
/// current block, given current on-chain conditions.
/// If the `FeeConfigTrait` is not defined for deposits, returns the full amount of shares.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Copy link
Collaborator

@immrsd immrsd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job! Left a couple of comments

self.reenter_target.write(target);
self.reenter_selector.write(selector);
for elem in calldata {
self.reenter_calldata.append().write(*elem);
Copy link
Collaborator

@immrsd immrsd Dec 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we clear the calldata that already exists to allow for a repeated call?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't have to. There's only one reentrant call since we're setting the reentrant Type to No in the hooks after the initial call

packages/utils/src/math.cairo Outdated Show resolved Hide resolved
packages/utils/src/math.cairo Outdated Show resolved Hide resolved
/// Those methods should be performed separately.
fn redeem(
ref self: TState, shares: u256, receiver: ContractAddress, owner: ContractAddress
) -> u256;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we include a newline between a function and doc for the next one?

packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
packages/token/src/erc20/extensions/erc4626/erc4626.cairo Outdated Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants